Skip to content

fix(chat): keep steering, action and injected messages in the conversation - #4816

Open
ericallam wants to merge 23 commits into
mainfrom
fix/chat-agent-accumulator
Open

fix(chat): keep steering, action and injected messages in the conversation#4816
ericallam wants to merge 23 commits into
mainfrom
fix/chat-agent-accumulator

Conversation

@ericallam

@ericallam ericallam commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

A steering message sent while the agent was answering

onTurnComplete: async ({ newUIMessages }) => {
  await db.saveMessages(newUIMessages);
},

Before: the steer reached the model for the answer it steered, and reached the browser, but never uiMessages or newUIMessages, so it was never saved and it disappeared on reload. Now it is in both.

The model also forgot it from the next turn onwards. chat.agent keeps a UI accumulator and a model accumulator, and the drain appended to the UI one only; the model saw the message through the prepareStep return value, which is per-step. The model lane is advanced by appending each turn's delta, so it never learned the message existed:

turn 1 accumulator → [user, steer, assistant]   the UI, the snapshot and chat.history.* all have it
turn 2 model prompt → [user, next-user]         the model answers as though it was never sent

The drain now hands back what it claimed and the model lane is appended to before the response is, so the order stays steer-then-answer. Appended rather than rebuilt from the UI lane: compaction replaces the model lane with a summary and deliberately leaves the UI lane whole, so a rebuild restores every message the summary had replaced. A first version of this fix did exactly that, caught in review; the steer was present in the next prompt and so was the whole pre-compaction transcript.

This is also the surface disagreement the QA lane reported: a recap in the same run recalled a mid-turn steer while the managed loop denied it. The recap was reading the persisted snapshot, which is written from the UI lane. Both now agree.

The same on chat.createSession() and chat.MessageAccumulator. Those keep their own accumulator, and the drain recorded what it claimed by pushing into a locals array only chat.agent populates, so there the push was a silent no-op. A mid-turn steer shaped that turn's answer and then existed nowhere: not in turn.uiMessages, not in turn.messages, and not queued as its own turn either. The drain now returns what it claimed and each surface records it in both of its lanes, appending for the same reason as above.

A steer on a turn that then fails. The error path built newUIMessages from the wire message and the partial only, so a turn that failed after a steer reported everything except the steer. It is now seeded from the per-turn list. This only affected a stream that rejects (a transport failure); an AI SDK error part completes the stream and was never affected.

An undo, edit, or regenerate

onAction: async ({ action }) => {
  if (action.type === "undo") chat.history.slice(0, -2);
},

Before: the rollback lived only in the running worker. It held while that worker stayed warm, then the next continuation booted from a snapshot that still contained the undone messages. They came back, minutes later, with no error. Now the action writes the snapshot.

A reply streamed back from an action

onAction: async ({ action, messages }) => {
  if (action.type === "regenerate") {
    chat.history.slice(0, -1);
    return streamText({ model, messages });
  }
},

Before: it reached the browser and nowhere else, so the user read a new answer the model had no memory of and the next turn carried on from the answer just replaced.

Now the captured message goes into the accumulator (replacing a message with the same id, otherwise appended, which is what makes a regenerate update the answer in place), the model-message lane is rebuilt from it, and the snapshot is written. The next turn's messages has it, and so does a continuation. A stream that fails part-way is no longer stored as though it finished either: the partial is kept, the failure reported.

Both action fixes are for platform-managed persistence. With hydrateMessages the runtime deliberately does not write, because your store is the source of truth, so a rollback and a streamed replacement are still yours to save, and chat.pipeAndCapture hands you the same message the runtime would have captured. The actions page now covers both models; it previously said only that persistence was your responsibility.

Injected system context

chat.inject([{ role: "system", content: "The user just upgraded to Pro." }]);

Before, on AI SDK 7: every provider rejected it (AI_InvalidPromptError from standardizePrompt, thrown before any provider call). The turn ended in the app's error fallback and persisted an assistant message with no parts, so the agent looked like it had stopped answering. Now it is appended to the model's instructions, where it is also treated as trusted, which is the reason to inject context in the first place.

Instructions are delivered by the helper, so a system-role injection needs it:

run: async ({ messages, signal }) =>
  streamText({
    ...chat.toStreamTextOptions(), // without this, a system injection never arrives
    model,
    messages,
    abortSignal: signal,
  }),

The conversational lane has no such requirement. An injection also applies to the next turn only, rather than repeating on every turn after it, and within that turn it is consumed once rather than once per read, so a run() that builds options more than once sees the same instructions in every build.

Verification

Each of the four has a test that fails without it, and each was run end to end against a deployed agent twice, once with the fix present and once with only that fix reverted, so the tests are known to fail in its absence rather than merely to pass in its presence. A 46-scenario sweep of the surrounding chat surface came back clean.

One later fix, recording only the steering messages a drain actually claimed, has unit coverage only: reproducing it needs a second consumer taking a record while shouldInject() awaits, which the deployed harness cannot produce.

The steering fix closes both halves: the durability one, and the model-context one that #4795 left behind as an expected-fail test. That test is now a passing test, verified red first (turn 2's user prompts came back without the steer).

The model-context fix, the createSession fix, the compaction interaction on both surfaces, and the failed-turn path were each run end to end against a deployed agent in both directions, with a runId guard confirming the later turns belonged to the same live run. One bundle carried the compaction regression on the createSession surface only: on it the compaction leg failed and the no-compaction steering leg passed, which is a direct demonstration that the earlier steering coverage was blind to the compaction interaction.

@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c9fe897

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/core Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
@trigger.dev/sso Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/testcontainers Patch
@internal/cache Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The SDK routes system injections into model instructions and keeps other injected messages in the conversation. Steering messages enter accumulated UI history and onTurnComplete.newUIMessages. Action responses and history mutations persist in snapshots while retaining the output cursor. The changes add documentation, patch changesets, harness support, handover coverage, and regression tests.

Merge Risk: 🟡 Moderate · up to e3318

The PR improves persistence for steering, action, and injected messages, but regenerated answers can still be duplicated in linear stores unless applications replace the old message atomically, and failed action snapshots can allow stale history to return later. Merge should wait for the persistence guidance and failure handling to be fixed or explicitly accepted.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: preserving steering, action, and injected messages in conversation history.
Description check ✅ Passed The description is detailed, on-topic, and explains the behavior changes, persistence models, testing, and edge cases. It does not follow the provided template headings and omits the issue-closing lin…
Full details: Docstring Coverage

Explanation

Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/chat-agent-accumulator

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ericallam ericallam changed the title fix(chat): keep steered, injected and action-produced messages in the conversation fix(chat): keep steering, action and injected messages in the conversation Aug 28, 2026
coderabbitai[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@c9fe897

trigger.dev

npm i https://pkg.pr.new/trigger.dev@c9fe897

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@c9fe897

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@c9fe897

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@c9fe897

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@c9fe897

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@c9fe897

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@c9fe897

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@c9fe897

commit: c9fe897

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/trigger-sdk/src/v3/ai.ts (2)

4267-4276: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep the model accumulator synchronized with injected steering messages.

Lines 4269-4276 update only chatCurrentUIMessagesKey. They do not update accumulatedMessages or turnNewModelMessages. After this response, the next turn in the same worker uses accumulatedMessages, so it omits steering that influenced the prior response. onTurnComplete.messages also omits that message.

Return the claimed UI and model messages from drainSteeringQueue(). Update both accumulator representations in each caller. Add a regression test that sends a steering message, completes the turn, then verifies the next run() call receives that message.


4267-4276: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add required development crumbs.

Add approved crumb instrumentation for these new behavior paths. If no approved namespace fits, ask before adding one.

  • packages/trigger-sdk/src/v3/ai.ts#L4267-L4276: add crumbs for steering claim and accumulator updates.
  • packages/trigger-sdk/test/inject-system-instructions.test.ts#L179-L234: add crumbs for the one-turn instruction-drain scenario.

As per coding guidelines, “Add crumbs as you write code — not just when debugging.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f069755c-6da5-41bc-8e27-c3b1350588ff

📥 Commits

Reviewing files that changed from the base of the PR and between fd2caf2 and ca7f086.

📒 Files selected for processing (6)
  • packages/trigger-sdk/src/v3/ai.ts
  • packages/trigger-sdk/test/action-snapshot.test.ts
  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/test/chatHandover.test.ts
  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/test/steering-accumulator.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/test/chatHandover.test.ts
  • packages/trigger-sdk/test/action-snapshot.test.ts
  • packages/trigger-sdk/test/steering-accumulator.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (43)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: sdk-compat / Node.js 20.20 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: sdk-compat / Node.js 24.18 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: internal / 🧪 Unit Tests: Internal
  • GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: sdk-compat / Node.js 22.23 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: fk-cascade-guard / fk-cascade-guard
  • GitHub Check: runops-guard / runops-guard
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: code-quality / code-quality
  • GitHub Check: Build and publish previews
🧰 Additional context used
📓 Path-based instructions (10)
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
We use vitest exclusively. **Never mock anything** - use testcontainers instead.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Add crumbs as you write code — not just when debugging. Mark lines with

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` (deprecated path alias)

📄 CodeRabbit inference engine (packages/trigger-sdk/CLAUDE.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
In the Trigger.dev SDK (packages/trigger-sdk), prefer isomorphic code like fetch and ReadableStream instead of Node.js-specific code

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Use vitest for all tests in the Trigger.dev repository

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
Use function declarations instead of default exports

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Use types over interfaces for TypeScript

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

Files:

  • packages/trigger-sdk/test/inject-system-instructions.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
🔇 Additional comments (1)
packages/trigger-sdk/test/inject-system-instructions.test.ts (1)

11-27: Replace mock-based chat coverage with the approved test strategy.

This test uses simulateReadableStream and MockLanguageModelV3. Use the repository Testcontainers-backed fixture instead.

As per coding guidelines, “We use vitest exclusively. Never mock anything - use testcontainers instead.”

Also applies to: 179-188

Source: Coding guidelines

@ericallam
ericallam marked this pull request as ready for review August 28, 2026 14:30
devin-ai-integration[bot]

This comment was marked as resolved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/trigger-sdk/src/v3/ai.ts (2)

4267-4276: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist claimed steering messages in raw accumulator paths.

drainSteeringQueue() updates only chatCurrentUIMessagesKey and chatTurnNewUIMessagesKey. ChatMessageAccumulator.prepareStep() and ChatTurn.prepareStep() pass their own queue into this helper, but neither raw accumulator is connected to these locals.

After a successful injection, the current inference sees the steering message. The next raw turn and caller-managed persistence do not see it because uiMessages and modelMessages were not updated.

Return the claimed UI and model messages, or add a callback that updates each raw accumulator after a successful claim. Deduplicate by message ID.


8036-8087: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add crumb markers for this new state transition.

Add // @Crumbs markers, or wrap this action stream capture and error path in a `// `#region` `@crumbs block. This path changes persistence order and error propagation.

As per coding guidelines, “Add crumbs as you write code — not just when debugging.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d29e0d9-c508-4a05-bf16-072254964879

📥 Commits

Reviewing files that changed from the base of the PR and between ca7f086 and 02c2e6b.

📒 Files selected for processing (3)
  • docs/ai-chat/background-injection.mdx
  • packages/trigger-sdk/src/v3/ai.ts
  • packages/trigger-sdk/test/action-stream-accumulator.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (42)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
  • GitHub Check: sdk-compat / Node.js 24.18 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Node.js 20.20 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: internal / 🧪 Unit Tests: Internal
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: fk-cascade-guard / fk-cascade-guard
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: runops-guard / runops-guard
  • GitHub Check: typecheck / typecheck
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: code-quality / code-quality
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Build and publish previews
🧰 Additional context used
📓 Path-based instructions (11)
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
We use vitest exclusively. **Never mock anything** - use testcontainers instead.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Add crumbs as you write code — not just when debugging. Mark lines with

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • docs/ai-chat/background-injection.mdx
  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` (deprecated path alias)

📄 CodeRabbit inference engine (packages/trigger-sdk/CLAUDE.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
In the Trigger.dev SDK (packages/trigger-sdk), prefer isomorphic code like fetch and ReadableStream instead of Node.js-specific code

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
Use vitest for all tests in the Trigger.dev repository

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
Use function declarations instead of default exports

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
MDX documentation pages must include frontmatter with title (required), description (required), and sidebarTitle (optional) in YAML format

📄 CodeRabbit inference engine (docs/CLAUDE.md)

Files:

  • docs/ai-chat/background-injection.mdx
Use types over interfaces for TypeScript

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

Files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
  • packages/trigger-sdk/src/v3/ai.ts
🧠 Learnings (2)
📚 Learning: 2026-06-16T09:19:47.637Z
Learnt from: d-cs
Repo: triggerdotdev/trigger.dev PR: 3960
File: apps/webapp/test/prismaInfrastructureErrorCapture.test.ts:0-0
Timestamp: 2026-06-16T09:19:47.637Z
Learning: In this repo’s Vitest setup, `vitest.config.ts` uses `globals: true`, so identifiers like `vi`, `describe`, `it`, and `expect` are available as globals in Vitest test files. During code review, do not flag missing `vi`/`describe`/`it`/`expect` imports as a runtime error or correctness issue when they’re used in `*.test.ts/tsx` or `*.spec.ts/tsx` files. Explicit imports are still preferred for consistency, but they’re not required for runtime behavior.

Applied to files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
📚 Learning: 2026-08-16T18:36:58.179Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 4537
File: packages/trigger-sdk/test/normalizeKeyString.test.ts:1-2
Timestamp: 2026-08-16T18:36:58.179Z
Learning: For related SDK `chat.agent` tests in the Trigger.dev repository—including chat channels, handover, snapshot, and transport-event coverage—keep new test files under `packages/trigger-sdk/test/` rather than colocating them with the `packages/trigger-sdk/src/v3/` source files.

Applied to files:

  • packages/trigger-sdk/test/action-stream-accumulator.test.ts
🔇 Additional comments (2)
docs/ai-chat/background-injection.mdx (1)

211-233: LGTM!

packages/trigger-sdk/test/action-stream-accumulator.test.ts (1)

99-171: LGTM!

The seed from payload.headStartMessages had no coverage for agents that do not
register hydrateMessages, and it reads unreachable: it sits inside
if (!hydrateMessages && couldHavePriorState), and couldHavePriorState is false on
a head-start run. It does fire, and this pins that.

Records the shape a persisting app has to handle, which is the part that actually
bites: by onTurnStart the accumulator is already ['user','assistant'], because
the warm route's partial is spliced in before the hook, so the incoming user
message is not the last one.
drainSteeringQueue used the injected uiMessage for span attributes, the
injection-confirmation chunk, the injected-ids set and onInjected — never the
accumulator. So the message reached the model and the browser, appeared in
neither uiMessages nor newUIMessages, and an app persisting from onTurnComplete
never learned it existed. The user steers, the agent obeys, the user reloads, and
their instruction is gone from the transcript and from every later turn's
context.

The asymmetry is the tell: a message that finds no step boundary falls back to
becoming its own turn and is accumulated normally. Only the path that worked lost
data.

Appended at injection time rather than turn end, so the order matches what
happened: after the message that started the turn, before the response that
answers it. Deduplicated by id, since a boundary can drain more than once.

The injection path had no test coverage at all — shouldInject appeared only in
ai.ts — because the harness had no way to deliver a message mid-turn. Adds
harness.sendPendingMessage() for that, which is also what a customer needs to
test steering in their own suite.
The snapshot is written on the turn-complete path, and an action is not a turn —
the block literally ends 'if (!isAction)'. So a chat.history mutation from
onAction lived only in the running worker's memory. Undo worked while that worker
stayed warm, then the next continuation booted from a snapshot still holding the
undone exchange and the messages came back. onAction is exactly where the docs
tell you to call rollbackTo, so this is the documented path silently not
persisting.

Writes the snapshot right after the action's override is applied, awaited for the
same reason as the turn-complete write: the agent may suspend straight after, and
in-flight promises do not reliably survive that.

An action has no turn cursor, so the write reuses the last one rather than
writing undefined — that would drop the resume point and make the next boot
replay from further back to rebuild what it could have read.
…ation

Returning a StreamTextResult from onAction piped it to the browser and stopped
there. The accumulator never saw it, no snapshot recorded it, and actions fire no
onTurnComplete — so the user read a good answer that the model had no memory of,
and the next turn carried on from the answer regenerate had just replaced. The
disagreement between the screen and the conversation was invisible until that
next turn contradicted it.

The action branch now captures what it pipes, using the pipeChatAndCapture that
already existed for exactly this, and appends the message to the accumulator.
Persistence beyond the snapshot is still the app's job, since an action fires no
turn hook — pipeAndCapture hands back the same message for that.

Also folds the snapshot write added for rolled-back history into one helper used
by both action paths, so a regenerate that both rolls back and answers writes
once rather than twice, and the cursor-preservation rule lives in one place.

The two fixes needed each other: with the rollback persisted but the response
dropped, a regenerate left the snapshot empty rather than stale — still wrong,
just differently.
chat.inject with role 'system' put the message into the conversation, which ai@7
rejects for every provider: standardizePrompt throws before any provider is
called. The next turn died with an error chunk reading 'An error occurred.' and
persisted an assistant message with no parts, so from the app's side the agent
had simply stopped answering.

The error message names the fix — use the instructions option — and Instructions
is string | SystemModelMessage | Array<SystemModelMessage>, so an injected system
block has a correct home. It is appended after the base prompt, which keeps the
prompt's position for caching and reads as a later amendment.

This makes the documented examples right rather than rewriting them to a
workaround. It also answers whether trusted mid-conversation context is
supportable: it is, and only this way. A message injected as 'user' is untrusted
by construction, and a well-aligned model says so and re-derives the answer from
tools instead. The docs now state which lane to use for facts and which for
directives.

A new instruction block changes the cached prefix, so the first call carrying it
misses the prompt cache. Only turns that actually injected pay it.
… paths

Record only the messages a steering drain actually claimed. The loop used the
offered batch, so a record another consumer took while shouldInject() awaited
was written into the accumulator for a turn it was never part of.

Drain the injected instructions once applied, matching the conversational
lane. Left in place they were re-applied by every later toStreamTextOptions()
call in the run, growing the prompt and changing its cached prefix each turn.

Clean a stopped action's partial response before it is committed, and skip
committing at all once the run is cancelled.
…finished

pipeChatAndCapture returns a stream failure rather than throwing it, so a
mid-stream failure in a response returned from onAction was committed as a
complete answer, snapshotted, and followed by a normal turn-complete with no
error — the browser saw the stream stop and the next turn built on the
truncated text. The partial is still kept; the failure is now surfaced with it.

Document that the instructions lane is delivered by chat.toStreamTextOptions(),
and that an injection applies to the next inference call only.
@ericallam
ericallam force-pushed the fix/chat-agent-accumulator branch from 02c2e6b to 52772b5 Compare August 28, 2026 20:47
The actions page said only that persistence was your responsibility inside
onAction, which is now wrong for platform-managed agents (the runtime writes
the snapshot) and too vague for app-owned ones, where a rollback and a
streamed replacement both need storing and there is no onTurnComplete to do
it in.
coderabbitai[bot]

This comment was marked as resolved.

The example saved the regenerated message without removing the one it
replaced, so a linear store would keep both and the next hydration would
return the pair. The undo branch already deleted; the regenerate branch now
does too, with a note that a history mutation is invisible to your database.
Drops the banned trivializing words, replaces future tense and "there is"
throat-clearing, and removes a "two things" lead-in that sat above three
bullets. Merges the two bullets that stated the same prompt-cache fact, and
stops claiming the injected block is appended as an array when it is merged
into a single instruction.
Recast each one as a comma, colon, parentheses, or two sentences rather than
swapping in a hyphen. Also removes a stray "simply", a future tense, and a
"had just been replaced" the previous pass missed in the changesets.
…ction pages

Covers the prose these pages already had, not only the new sections: the
frontmatter descriptions, code comments, the message-role table cell, the
injection-point list, and the see-also link descriptions. Each recast as a
colon, comma, parentheses, or two sentences.
Both stay patch. The double write only bites code that worked around a lost
message, and the silent action completion was the bug it now reports, so
neither is new functionality or an API break. The version cannot carry either
signal, so the changelog entries name them instead.

Also documents sendPendingMessage in the testing harness table, which listed
every other send method.
Draining the lane on read handed the injection to whichever
chat.toStreamTextOptions() call ran first and dropped it from the rest. A
run() that builds options twice, a classifier pass and then the answer, sent
the instruction to nobody if it passed the second one to streamText, with no
error anywhere. Consumption is now keyed on the turn, so every build in the
turn carries the same instructions and the turn after it carries none. A
hand-rolled loop with no turn context still drains on read.
devin-ai-integration[bot]

This comment was marked as resolved.

Consuming the instructions lane marked the blocks read but left them in it, so
an injection made in that turn's onTurnComplete queued behind them and the next
turn's clear destroyed both. Turn 1 carried its instruction and every turn after
it silently carried none, which is worse than the per-read draining it replaced.

The consumed blocks now move to turn-scoped state, so a second options build in
the same turn still sees them while the lane holds only what is pending. Also
guards the stash lookup: outside a turn both sides of the turn comparison are
undefined, so the optional-chained check matched and dereferenced nothing.
The UI and model accumulators are maintained separately, and a drained
message was appended to the UI one only. The model saw it through the
prepareStep return value, which is per-step, so the model lane never
learned it existed and every later turn of the run answered without it
while the browser, the snapshot and chat.history.* all still showed it.

The drain now marks the model lane stale and it is rebuilt from the UI
lane at the end of the turn. Flips the it.fails repro in
steering-injection.test.ts to a passing test.
devin-ai-integration[bot]

This comment was marked as resolved.

…esponse

A run() that pipes the stream itself skips the auto-pipe, so no onFinish
is attached and nothing is captured. The rebuild sits outside both
capturedResponseMessage branches for that reason. Gating it on a
captured response fails this test and leaves the other one passing.
drainSteeringQueue reported what it claimed by pushing into a locals
array that only chat.agent populates, so on chat.createSession and
chat.MessageAccumulator the push was a silent no-op behind its
truthiness guard. A mid-turn steer shaped that turn's answer and then
existed nowhere: not in the session's uiMessages, not in its
modelMessages, and not deferred to its own turn either.

The drain now returns what it claimed alongside what to inject, and each
surface files it. Adds absorbSteering to the accumulator, used by both
of its drain sites.
devin-ai-integration[bot]

This comment was marked as resolved.

Reconciling the model lane by reconverting the UI lane assumed the UI
lane is a superset of it. Compaction breaks that by design: it replaces
the model lane with a summary and deliberately leaves the UI lane whole,
so any reconversion restored every message the summary had replaced.
Reproduced on both surfaces: the next turn was sent the full transcript
with no summary, while the steer itself was present, which is what made
a steer-presence check pass.

The model lane is now only ever appended to. The stale flag becomes the
claimed messages themselves, reconciled before the response is appended
so the order stays steer-then-answer, and onto whatever the lane holds,
summary included. absorbSteering appends to both lanes the same way.
The error path built newUIMessages from the wire message and the partial
only, never from the per-turn list the drain appends to, so a turn that
failed after a steer handed onTurnComplete everything except the steer.
Seeded from the per-turn list at both construction sites, deduped by id.
Reproduced with a stream that rejects mid-answer; an AI SDK error part
takes the normal completion path and was never affected.
An action and the message after it share a turn number, so the action
reads the pending instruction and the intended turn still receives it,
and it does not carry to the turn after. Removing the per-turn stash
makes the action consume it and the intended turn gets nothing, which
is the failure the stash prevents.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Devin Review

* normal turn-complete and the truncated answer is persisted
* as if it were finished — the next turn then builds on it.
*/
if (captured.status === "error") throw captured.error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Failed actions consume conversation turns

When an action stream fails, throw captured.error enters the turn-error path without restoring the counter. It fires onTurnComplete and consumes one-shot instructions and maxTurns capacity.

Prompt for agents
Handle action failures separately from normal turn failures in packages/trigger-sdk/src/v3/ai.ts. The new captured stream error at throw captured.error reaches the shared catch before the success-only turn-- executes. Preserve the action contract on failures: do not advance the turn counter, do not invoke turn lifecycle hooks, and keep pending one-shot instructions available for the next real turn. Still emit the action error and completion control chunks, and persist any captured partial response for platform-managed history.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +6735 to +6741
await writeChatSnapshot<TUIMessage>(sessionIdForSnapshot, {
version: 1,
savedAt: Date.now(),
messages: accumulatedUIMessages,
lastOutEventId: lastSnapshotOutEventId,
lastInEventId:
snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Actions regress failed-turn snapshots

After a failed turn, lastSnapshotOutEventId remains stale. A later history-changing action writes that older cursor, so continuation replay can restore superseded output.

Prompt for agents
Keep lastSnapshotOutEventId synchronized with every snapshot boundary, including the error-path snapshot in packages/trigger-sdk/src/v3/ai.ts. Currently only the successful turn snapshot updates the holder. After an error writes errorTurnCompleteResult.lastEventId, a later writeSnapshotOutsideTurn uses the stale holder. Update the shared cursor when the error completion succeeds, while preserving the cursor-neutral behavior of action snapshots.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +8561 to +8567
const pendingSteer = locals.get(chatPendingSteerKey);
if (pendingSteer && pendingSteer.length > 0) {
locals.set(chatPendingSteerKey, []);
try {
accumulatedMessages.push(
...(await toModelMessages(pendingSteer.map(stripProviderMetadata)))
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Steering transformations vanish after one step

When pendingMessages.prepare transforms a steer, toModelMessages stores its original form instead. Later managed and accumulator turns receive different context from the steered turn.

Prompt for agents
Preserve the ModelMessage[] returned by pendingMessages.prepare when recording consumed steering for future turns. The managed chat.agent path currently stores UIMessage[] in chatPendingSteerKey and reconverts them at turn end. ChatMessageAccumulator.absorbSteering, used directly and by createSession, also reconverts claimed UI messages. Carry both the claimed UI messages for display and persistence and the actual injected model messages for the model accumulator. Keep the compaction-safe append behavior and deduplicate consistently.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +9215 to +9225
const buildErroredNew = (): TUIMessage[] => {
const out: TUIMessage[] = [];
const addUnique = (m?: TUIMessage) => {
if (m && !out.some((existing) => existing.id === m.id)) out.push(m);
};
addUnique(erroredWireMessage);
for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? []) as TUIMessage[]) {
addUnique(m);
}
if (includePartial) addUnique(partialResponse!);
return out;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Next turn forgets failed steering

When a turn fails after consuming a steer, buildErroredNew reports it only to the hook. The next turn's model prompt still omits that steer.

Prompt for agents
Reconcile chatPendingSteerKey into accumulatedMessages on the error path before the next turn begins. The success path does this around the pendingSteer block, but the error path only includes steering in newUIMessages. Preserve model-only compaction, append the steer before any partial assistant response, clear the pending state only after successful conversion, and ensure the onTurnComplete error event sees the same model history that the next turn will use.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

The bound streamText on the run and onAction arguments is #4884's, so
on this branch alone the test neither typechecked nor ran.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants